Skip to content

feat(ai-client,ai-react): add fetcher option to ChatClient/useChat#512

Open
tombeckenham wants to merge 7 commits intoTanStack:mainfrom
tombeckenham:claude/usechat-fetcher-alternative
Open

feat(ai-client,ai-react): add fetcher option to ChatClient/useChat#512
tombeckenham wants to merge 7 commits intoTanStack:mainfrom
tombeckenham:claude/usechat-fetcher-alternative

Conversation

@tombeckenham
Copy link
Copy Markdown
Contributor

@tombeckenham tombeckenham commented Apr 28, 2026

Closes #509.

🎯 Changes

Adds a fetcher option to ChatClient and useChat, mirroring the fetcher option that already exists on the generation hooks (useGenerateSpeech, useSummarize, useTranscription, useGenerateImage). This gives useChat a first-class way to accept a TanStack Start server function (or any other async request function) without going through a connection adapter.

useChat({
  fetcher: ({ messages }, { signal }) =>
    chatFn({ data: { messages }, signal }),
})

Why

Issue #509 reports that wiring a server function into useChat via the existing stream() connection adapter fails to typecheck:

Property '[Symbol.asyncIterator]' is missing in type 'Promise<Response>'
but required in type 'AsyncIterable<AGUIEvent>'. ts(2741)

stream()'s factory is typed as () => AsyncIterable<StreamChunk>, but a server function call is async and returns either Promise<Response> (when the handler returns toServerSentEventsResponse(...)) or Promise<AsyncIterable<StreamChunk>> (when it returns the stream directly). Neither shape is currently assignable without as any.

Rather than widening stream()'s factory signature, this PR introduces fetcher — a sibling option to connection whose type is built around exactly this async/Response/AsyncIterable shape, and which matches the mental model users already have from the multimedia hooks.

Shape

export interface ChatFetcherInput {
  messages: Array<UIMessage>
  data?: Record<string, unknown>
}
export interface ChatFetcherOptions { signal: AbortSignal }
export type ChatFetcher = (
  input: ChatFetcherInput,
  options: ChatFetcherOptions,
) => Promise<Response | AsyncIterable<StreamChunk>>

ChatClientOptions now extends a ChatTransport discriminated union — exactly one of connection or fetcher is required, enforced at the type level (no runtime XOR check needed).

Internals

  • fetcherToConnectionAdapter(fetcher) wraps a ChatFetcher into a regular ConnectionAdapter. The chat client doesn't gain any new code paths — the fetcher rides the existing normalizeConnectionAdapter queue plumbing that already powers SSE / HTTP-stream / stream() / rpcStream(). Aborts, retries, and live: true all work unchanged.
  • SSE Response parsing is factored out of fetchServerSentEvents into a shared responseToSSEChunks helper, reused by the fetcher path when a fetcher resolves to a Response.
  • stream(), rpcStream(), fetchServerSentEvents, and fetchHttpStream are unchanged.

What's in the diff

  • packages/typescript/ai-client/src/types.tsChatFetcher / ChatFetcherInput / ChatFetcherOptions / ChatTransport types
  • packages/typescript/ai-client/src/chat-client.ts — accept either connection or fetcher in the constructor and updateOptions
  • packages/typescript/ai-client/src/connection-adapters.tsfetcherToConnectionAdapter helper + extracted responseToSSEChunks
  • packages/typescript/ai-client/src/index.ts — re-export new types
  • packages/typescript/ai-react/src/{types.ts,use-chat.ts,index.ts} — pass fetcher through
  • packages/typescript/ai-{solid,vue,svelte,preact}/... — same one-line fetcher pass-through in each framework hook
  • packages/typescript/ai-client/tests/chat-fetcher.test.ts — 7 cases covering Response / AsyncIterable / abort / error / body merging
  • packages/typescript/ai-react/tests/use-chat-fetcher.test.ts — React hook coverage
  • examples/ts-react-chat/{src/lib/server-fns.ts, src/routes/server-fn-chat.tsx, src/components/Header.tsx} — example wiring a TanStack Start server function via the new fetcher option, demonstrating the pattern from the issue
  • testing/e2e/... — e2e coverage for the fetcher path
  • .changeset/usechat-fetcher-server-functions.md

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added fetcher option to chat client and hooks as an alternative to connection-based transport. Supports returning either an SSE Response or AsyncIterable stream.
    • Introduced new type-safe transport configuration enforcing mutual exclusivity between connection and fetcher options.
    • Added example demonstrating server function-based chat integration.
  • Bug Fixes & Improvements

    • Enhanced stream parsing with improved error handling for truncated and malformed data.

Mirrors the `fetcher` option on the multimedia hooks (useGenerateSpeech /
useSummarize / useTranscription / useGenerateImage). Pass either
`connection` (a ConnectionAdapter) or `fetcher` (a direct async function
— typically a TanStack Start server function) — runtime XOR validation.

The fetcher may return either a Response (parsed as SSE) or an
AsyncIterable<StreamChunk> (yielded directly). Internally, fetcher is
wrapped via `fetcherToConnectionAdapter` and reuses the same subscribe/
send queue plumbing as every other connection adapter — no new code
paths in ChatClient itself.

Purely additive: stream(), rpcStream(), fetchServerSentEvents(), and
fetchHttpStream() are unchanged. Other framework wrappers (ai-solid,
ai-vue, ai-svelte) untouched in this branch — same shape can be added
to each in a follow-up if this design is preferred.

Sketch alternative to TanStack#508 (the stream() connection-adapter approach)
for design comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

This change introduces a fetcher transport option for ChatClient and framework chat hooks as an alternative to the existing connection adapter. It includes type enforcement ensuring mutual exclusivity between connection and fetcher, support for both AsyncIterable<StreamChunk> and Response (SSE) fetcher return types, updated streaming utilities to handle new shapes, and comprehensive tests and examples demonstrating the new capability.

Changes

Cohort / File(s) Summary
Type Definitions
packages/typescript/ai-client/src/types.ts, packages/typescript/ai-client/src/index.ts
Added ChatFetcher, ChatFetcherInput, ChatFetcherOptions, and ChatTransport types. ChatTransport enforces XOR-style mutual exclusivity between connection and fetcher. Updated ChatClientOptions to require exactly one transport mechanism via discriminated union.
Core Chat Client
packages/typescript/ai-client/src/chat-client.ts
Introduced resolveTransport helper to validate and normalize fetcher/connection options. Updated constructor and updateOptions method to accept optional fetcher alongside connection.
Connection Adapters
packages/typescript/ai-client/src/connection-adapters.ts
Added StreamTruncatedError class for stream truncation detection. Introduced responseToSSEChunks() for centralized SSE parsing and fetcherToConnectionAdapter() for adapting fetchers into the connection adapter flow. Updated normalizeConnectionAdapter to synthesize terminal events (RUN_FINISHED, RUN_ERROR). Modified stream() and rpcStream() signatures to accept abortSignal parameter. Changed readStreamLines() to throw on unterminated buffers instead of yielding partial content.
React Framework Integration
packages/typescript/ai-react/src/use-chat.ts, packages/typescript/ai-react/src/index.ts, packages/typescript/ai-react/src/types.ts
Updated useChat to conditionally provide connection or fetcher to ChatClient. Added re-exports of ChatFetcher types and rpcStream. Updated JSDoc for UseChatOptions to document mutually exclusive transport options.
Preact Framework Integration
packages/typescript/ai-preact/src/use-chat.ts
Updated useChat to build transport configuration based on available connection or fetcher, spreading into ChatClient constructor.
Solid Framework Integration
packages/typescript/ai-solid/src/use-chat.ts
Modified ChatClient initialization to conditionally provide connection or fetcher via transport object.
Svelte Framework Integration
packages/typescript/ai-svelte/src/create-chat.svelte.ts
Updated derived transport object to provide either connection or fetcher to client initialization.
Vue Framework Integration
packages/typescript/ai-vue/src/use-chat.ts
Modified client setup to conditionally pass connection or fetcher in constructor options.
Chat Client Tests
packages/typescript/ai-client/tests/chat-client.test.ts
Updated error message assertion to reflect requirement for "either `connection` or `fetcher`" instead of connection-only.
Fetcher Tests
packages/typescript/ai-client/tests/chat-fetcher.test.ts
Added comprehensive test suite verifying fetcher behavior: AsyncIterable streaming, SSE Response parsing, abort signal propagation, error handling, message composition, and validation of transport mutual exclusivity.
Connection Adapter Tests
packages/typescript/ai-client/tests/connection-adapters.test.ts
Updated tests to expect SyntaxError on malformed JSON instead of graceful skipping. Changed [DONE] handling to verify synthesis of RUN_FINISHED terminal chunk. Updated expectations for stream and rpcStream to include undefined abort signal parameter.
React Hook Tests
packages/typescript/ai-react/tests/use-chat-fetcher.test.ts
Added test suite for useChat with fetcher transport, covering AsyncIterable and SSE response paths, error handling, abort signal propagation, and request shaping validation.
Example App
examples/ts-react-chat/src/lib/server-fns.ts, examples/ts-react-chat/src/routes/server-fn-chat.tsx, examples/ts-react-chat/src/components/Header.tsx, examples/ts-react-chat/src/routeTree.gen.ts
Added new server function chat example with chatFn POST server function returning SSE stream, new /server-fn-chat route with UI component, navigation link in header, and updated route tree.
E2E Testing
testing/e2e/src/routes/$provider/$feature.tsx, testing/e2e/tests/chat.spec.ts
Updated ChatFeature to accept optional mode prop, implementing fetcher transport when mode === 'fetcher'. Added new test case for fetcher-mode streaming across all providers.
Changeset Documentation
.changeset/usechat-fetcher-server-functions.md
Documented API change introducing fetcher option with contract definitions for return types and mutual exclusivity requirement with connection.

Sequence Diagram(s)

sequenceDiagram
    participant App as App/Hook
    participant Client as ChatClient
    participant Adapter as fetcherToConnectionAdapter
    participant Fetcher as ChatFetcher
    participant Parser as responseToSSEChunks

    App->>Client: new ChatClient({ fetcher })
    activate Client
    Client->>Client: resolveTransport(fetcher)
    Client->>Adapter: fetcherToConnectionAdapter(fetcher)
    deactivate Client
    
    App->>Client: sendMessage()
    activate Client
    Client->>Adapter: send(request)
    activate Adapter
    Adapter->>Fetcher: fetcher({ messages, data, signal })
    activate Fetcher
    Fetcher-->>Adapter: Response | AsyncIterable<StreamChunk>
    deactivate Fetcher
    
    alt Response Path
        Adapter->>Parser: responseToSSEChunks(response)
        activate Parser
        Parser-->>Adapter: AsyncIterable<StreamChunk>
        deactivate Parser
    else AsyncIterable Path
        Adapter->>Adapter: use iterable directly
    end
    
    loop For each chunk
        Adapter-->>Client: StreamChunk
        Client-->>App: onMessage/message update
    end
    
    Adapter-->>Client: RUN_FINISHED (synthesized)
    deactivate Adapter
    Client-->>App: ready state
    deactivate Client
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The changes introduce significant new control flow and type logic across multiple layers. The connection adapters refactor (connection-adapters.ts) is dense with new streaming utilities and error handling. While framework integrations follow a repetitive pattern, each requires verification against its specific context. Comprehensive test coverage and multiple affected domains necessitate careful cross-file reasoning.

Poem

🐰 Whiskers twitch with delight,
A fetcher bounds through the light!
No connection chains,
Just server domain trains,
Responses stream left and right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: adding a fetcher option to ChatClient and useChat hooks.
Description check ✅ Passed The PR description is comprehensive, covers all required template sections (Changes, Checklist, Release Impact), includes well-structured rationale and implementation details.
Linked Issues check ✅ Passed The PR fully addresses issue #509 by introducing a fetcher option with proper TypeScript typing that allows server functions to work with useChat without type errors.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objective: adding fetcher support to ChatClient/useChat. No unrelated refactoring or features are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Apr 28, 2026

View your CI Pipeline Execution ↗ for commit ebec591

Command Status Duration Result
nx run-many --targets=build --exclude=examples/** ✅ Succeeded 1m 9s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-28 10:14:55 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 28, 2026

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/@tanstack/ai@512

@tanstack/ai-anthropic

npm i https://pkg.pr.new/@tanstack/ai-anthropic@512

@tanstack/ai-client

npm i https://pkg.pr.new/@tanstack/ai-client@512

@tanstack/ai-code-mode

npm i https://pkg.pr.new/@tanstack/ai-code-mode@512

@tanstack/ai-code-mode-skills

npm i https://pkg.pr.new/@tanstack/ai-code-mode-skills@512

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/@tanstack/ai-devtools-core@512

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/@tanstack/ai-elevenlabs@512

@tanstack/ai-event-client

npm i https://pkg.pr.new/@tanstack/ai-event-client@512

@tanstack/ai-fal

npm i https://pkg.pr.new/@tanstack/ai-fal@512

@tanstack/ai-gemini

npm i https://pkg.pr.new/@tanstack/ai-gemini@512

@tanstack/ai-grok

npm i https://pkg.pr.new/@tanstack/ai-grok@512

@tanstack/ai-groq

npm i https://pkg.pr.new/@tanstack/ai-groq@512

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-isolate-cloudflare@512

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/@tanstack/ai-isolate-node@512

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/@tanstack/ai-isolate-quickjs@512

@tanstack/ai-ollama

npm i https://pkg.pr.new/@tanstack/ai-ollama@512

@tanstack/ai-openai

npm i https://pkg.pr.new/@tanstack/ai-openai@512

@tanstack/ai-openrouter

npm i https://pkg.pr.new/@tanstack/ai-openrouter@512

@tanstack/ai-preact

npm i https://pkg.pr.new/@tanstack/ai-preact@512

@tanstack/ai-react

npm i https://pkg.pr.new/@tanstack/ai-react@512

@tanstack/ai-react-ui

npm i https://pkg.pr.new/@tanstack/ai-react-ui@512

@tanstack/ai-solid

npm i https://pkg.pr.new/@tanstack/ai-solid@512

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/@tanstack/ai-solid-ui@512

@tanstack/ai-svelte

npm i https://pkg.pr.new/@tanstack/ai-svelte@512

@tanstack/ai-vue

npm i https://pkg.pr.new/@tanstack/ai-vue@512

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/@tanstack/ai-vue-ui@512

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/@tanstack/preact-ai-devtools@512

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/@tanstack/react-ai-devtools@512

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/@tanstack/solid-ai-devtools@512

commit: ced9669

tombeckenham and others added 2 commits April 28, 2026 13:05
…ranch

The fetcher path uses the same SSE parsing and connect-wrapper plumbing
as the stream() path on TanStack#508, so the polish that landed during TanStack#508's
review applies directly here. Carry it over so this branch has the same
robustness.

- Skip SSE control lines (`:` comments, `event:` / `id:` / `retry:`) in
  responseToSSEChunks. Proxies and CDNs inject these as keepalives;
  letting them through would feed JSON.parse a non-payload line.
- Drop unterminated trailing buffer in readStreamLines. A non-empty
  buffer at stream end means the connection was cut mid-line, so the
  data is partial — yielding it would surface a misleading RUN_ERROR
  for what is really a transport-layer issue.
- Surface JSON.parse failures in responseToSSEChunks and fetchHttpStream.
  Stop swallowing them behind console.warn; let SyntaxError propagate so
  the connect-wrapper turns it into a visible RUN_ERROR.
- Drop unsafe `as unknown as StreamChunk` casts in
  normalizeConnectionAdapter's synthesized RUN_FINISHED / RUN_ERROR
  events. Use EventType + RunFinishedEvent / RunErrorEvent so missing
  required fields are caught by the compiler. Track upstream
  threadId/runId from chunks and reuse them in the synthesis instead of
  fabricating both ids unconditionally.
- Forward optional abortSignal third arg through stream() and rpcStream()
  factory signatures. Backwards-compatible for existing callers; lets
  long-running factories cancel when useChat aborts. Mirrors what
  fetcherToConnectionAdapter already does.

Tests:
- Update the two `should handle malformed JSON gracefully` tests to
  assert SyntaxError throws instead of silent drop.
- Update stream() / rpcStream() factory mock assertions to expect the new
  third arg.
- Add chat-fetcher test asserting a fetcher returning a malformed-SSE
  Response surfaces as a RUN_ERROR via onError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… type

Promote `ChatClientOptions` to a discriminated union so exactly one of
`connection` or `fetcher` is required at the type level, surface stream
truncation as a `StreamTruncatedError` instead of a silent warn, synthesize
RUN_FINISHED on legacy `[DONE]` sentinels, and abort fetcher-returned
async iterables that ignore their signal. Update framework wrappers
(react/preact/solid/svelte/vue) and the e2e route to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tombeckenham tombeckenham changed the title [draft] alt to #508: add fetcher option on useChat (mirrors useGenerateSpeech) feat(ai-client,ai-react): add fetcher option to ChatClient/useChat Apr 28, 2026
tombeckenham and others added 3 commits April 28, 2026 16:32
…preading a partial transport

Mirrors the pattern in `useGeneration` (multimedia hooks): build a `baseOptions`
literal once, then call `new ChatClient(...)` in two narrow branches with the
matching transport. Drops the `optionsRef.current.fetcher!` non-null assertion
and the awkward discriminated-union spread, and provides a clear hook-level
error when neither `connection` nor `fetcher` is provided.

Applied to all five chat hooks: ai-react, ai-preact, ai-solid, ai-vue,
ai-svelte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `@tanstack/ai-preact`, `ai-solid`, `ai-svelte`, and `ai-vue` to the
fetcher changeset as minor bumps — they all expose the new `fetcher`
option transitively via `ChatClientOptions`. Also simplify the hooks
to pick the transport into a single object before constructing
`ChatClient`, instead of duplicating the option bag in if/else branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ai-preact`, `ai-solid`, `ai-svelte`, and `ai-vue` don't add any new
public exports — they only adjust internal plumbing to handle the new
connection/fetcher XOR shape from `ai-client`. `ai-react` stays minor
because it genuinely re-exports new symbols (`rpcStream`, `ChatFetcher`,
`ChatFetcherInput`, `ChatFetcherOptions`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tombeckenham tombeckenham marked this pull request as ready for review April 28, 2026 10:21
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
testing/e2e/src/routes/$provider/$feature.tsx (1)

17-26: ⚠️ Potential issue | 🟡 Minor

Whitelist mode instead of casting arbitrary strings.

search.mode as Mode accepts any string from the URL, so validateSearch no longer guarantees a valid transport mode. That can leak invalid state into downstream components and defeat the purpose of the search validation.

Suggested fix
-      mode: typeof search.mode === 'string' ? (search.mode as Mode) : undefined,
+      mode:
+        search.mode === 'sse' || search.mode === 'fetcher'
+          ? search.mode
+          : undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testing/e2e/src/routes/`$provider/$feature.tsx around lines 17 - 26,
validateSearch currently casts any string from search.mode to Mode using
(search.mode as Mode), allowing invalid modes; instead implement a whitelist:
define the allowed Mode values (the Mode union/enum) and check that typeof
search.mode === 'string' and allowedModes.includes(search.mode as Mode) before
returning it, otherwise return undefined. Update the validateSearch function to
perform this include-check (referencing validateSearch, Mode, and search.mode)
so only known/allowed transport modes are propagated.
🧹 Nitpick comments (1)
packages/typescript/ai-client/src/chat-client.ts (1)

33-51: Prefer Zod for transport XOR runtime validation.

resolveTransport does manual shape validation; this should use a Zod schema to stay consistent with validation patterns in packages/typescript/**/src/**/*.ts.

As per coding guidelines: packages/typescript/**/src/**/*.ts: "Use Zod for schema validation and tool definition across the library".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-client/src/chat-client.ts` around lines 33 - 51, The
resolveTransport function currently does manual runtime checks for connection vs
fetcher; replace that logic with a Zod schema that enforces an exclusive-or
(XOR) between ConnectionAdapter and ChatFetcher and parses the input, then use
the parsed result to return either the ConnectionAdapter or the result of
fetcherToConnectionAdapter(fetcher). Specifically, create a Zod object schema
(using z.object and z.union or z.discriminatedUnion / refine to enforce XOR)
that accepts { connection?: ConnectionAdapter } or { fetcher?: ChatFetcher },
validate the incoming transport with that schema inside resolveTransport, and
throw the same descriptive errors if validation fails; keep using the same
symbols resolveTransport, ConnectionAdapter, ChatFetcher, and
fetcherToConnectionAdapter so callers and behavior remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/ts-react-chat/src/lib/server-fns.ts`:
- Around line 380-394: Replace the no-op inputValidator on chatFn with a real
runtime schema that validates the overall payload shape (e.g., an object with
messages: Array<UIMessage> and optional data), validate each message fields used
by chat (role, content, id, etc.), and remove the unsafe cast "data.messages as
any" so the handler passes the typed/validated messages into chat; update the
inputValidator call to use that schema (or a Zod schema) and ensure
chatFn.handler expects the validated type before calling
chat/openaiText/toServerSentEventsResponse.

In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx`:
- Around line 31-33: The fetcher example passed to useChat uses the wrong call
shape for chatFn; update the fetcher so it forwards the messages correctly by
calling chatFn with data containing messages and the signal (i.e., call chatFn
with an object whose data property wraps messages and includes signal) — locate
the useChat fetcher and replace the current chatFn({ data, signal }) invocation
with the correct shape chatFn({ data: { messages }, signal }) so copy/pasted
examples work as intended.
- Line 18: The handler signature uses React.FormEvent but the React namespace
isn't available under strict TS settings; update imports to include the type and
switch the parameter type: import type { FormEvent } from 'react' and change the
handleSubmit signature from (e: React.FormEvent) to (e: FormEvent) in the
handleSubmit function so the type resolves under moduleResolution: "bundler" and
allowUmdGlobalAccess: false.

In `@packages/typescript/ai-client/src/connection-adapters.ts`:
- Around line 139-150: The code is JSON.parse'ing untrusted stream data and
casting to StreamChunk without validation; update the parsing to validate
against a Zod schema (define a StreamChunkSchema using zod matching StreamChunk
fields) and use StreamChunkSchema.parse or safeParse on the parsed object before
assigning values or yielding; replace direct casts in the block that sets
lastThreadId, lastRunId, lastModel and yields chunk (and the similar occurrences
around the other mentioned locations) with the validated result, handling
validation failures (log/skip/break as appropriate) instead of trusting the
cast.
- Around line 78-83: The trailing-buffer check is misclassifying an intentional
abort as a StreamTruncatedError; update the validation so it only throws
StreamTruncatedError when the stream ended unexpectedly, not when the operation
was aborted. Specifically, in the block that inspects buffer (the code that
currently does if (buffer.trim()) { throw new StreamTruncatedError() }), add a
guard that detects an abort (e.g., check controller.signal?.aborted or a
local/instance flag set by stop()) and skip throwing if aborted—only throw
StreamTruncatedError when buffer.trim() is true AND the abort signal/stop flag
is false. Ensure you reference and use the existing stop() abort mechanism and
the StreamTruncatedError symbol so behavior remains unchanged for genuine
truncation.

In `@packages/typescript/ai-client/tests/chat-client.test.ts`:
- Around line 91-95: Add a regression test that exercises the "both provided"
branch of the ChatClient constructor: construct a new ChatClient passing both a
`connection` and a `fetcher` (e.g., stub/mocked objects) and assert it throws
the same constructor error (the message about requiring either `connection` or
`fetcher`). Target the ChatClient constructor in the existing test file and
mirror the existing pattern used in the empty-transport test so both branches
(none provided and both provided) are covered.

In `@testing/e2e/tests/chat.spec.ts`:
- Around line 26-40: The test 'fetcher mode — streams an SSE Response through
useChat({ fetcher })' currently mirrors the SSE flow and needs a
fetcher-specific assertion; update the test (the async test block using
featureUrl, sendMessage, waitForResponse, getLastAssistantMessage) to
additionally assert a fetcher-only signal—for example, check the UI mode
indicator element text/value that shows "fetcher" is selected after page.goto,
or intercept and inspect the outgoing request body to confirm it used the
fetcher payload shape (e.g., contains a fetcher flag or specific header) before
sending the message; use existing helpers on the page to query the mode element
or route/intercept the request and add an expect that fails if the fetcher
marker is absent.

---

Outside diff comments:
In `@testing/e2e/src/routes/`$provider/$feature.tsx:
- Around line 17-26: validateSearch currently casts any string from search.mode
to Mode using (search.mode as Mode), allowing invalid modes; instead implement a
whitelist: define the allowed Mode values (the Mode union/enum) and check that
typeof search.mode === 'string' and allowedModes.includes(search.mode as Mode)
before returning it, otherwise return undefined. Update the validateSearch
function to perform this include-check (referencing validateSearch, Mode, and
search.mode) so only known/allowed transport modes are propagated.

---

Nitpick comments:
In `@packages/typescript/ai-client/src/chat-client.ts`:
- Around line 33-51: The resolveTransport function currently does manual runtime
checks for connection vs fetcher; replace that logic with a Zod schema that
enforces an exclusive-or (XOR) between ConnectionAdapter and ChatFetcher and
parses the input, then use the parsed result to return either the
ConnectionAdapter or the result of fetcherToConnectionAdapter(fetcher).
Specifically, create a Zod object schema (using z.object and z.union or
z.discriminatedUnion / refine to enforce XOR) that accepts { connection?:
ConnectionAdapter } or { fetcher?: ChatFetcher }, validate the incoming
transport with that schema inside resolveTransport, and throw the same
descriptive errors if validation fails; keep using the same symbols
resolveTransport, ConnectionAdapter, ChatFetcher, and fetcherToConnectionAdapter
so callers and behavior remain unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 325740fd-64b9-4f1b-8045-6509c9bb580f

📥 Commits

Reviewing files that changed from the base of the PR and between ff33855 and ced9669.

📒 Files selected for processing (22)
  • .changeset/usechat-fetcher-server-functions.md
  • examples/ts-react-chat/src/components/Header.tsx
  • examples/ts-react-chat/src/lib/server-fns.ts
  • examples/ts-react-chat/src/routeTree.gen.ts
  • examples/ts-react-chat/src/routes/server-fn-chat.tsx
  • packages/typescript/ai-client/src/chat-client.ts
  • packages/typescript/ai-client/src/connection-adapters.ts
  • packages/typescript/ai-client/src/index.ts
  • packages/typescript/ai-client/src/types.ts
  • packages/typescript/ai-client/tests/chat-client.test.ts
  • packages/typescript/ai-client/tests/chat-fetcher.test.ts
  • packages/typescript/ai-client/tests/connection-adapters.test.ts
  • packages/typescript/ai-preact/src/use-chat.ts
  • packages/typescript/ai-react/src/index.ts
  • packages/typescript/ai-react/src/types.ts
  • packages/typescript/ai-react/src/use-chat.ts
  • packages/typescript/ai-react/tests/use-chat-fetcher.test.ts
  • packages/typescript/ai-solid/src/use-chat.ts
  • packages/typescript/ai-svelte/src/create-chat.svelte.ts
  • packages/typescript/ai-vue/src/use-chat.ts
  • testing/e2e/src/routes/$provider/$feature.tsx
  • testing/e2e/tests/chat.spec.ts

Comment on lines +380 to +394
export const chatFn = createServerFn({ method: 'POST' })
.inputValidator(
(data: { messages: Array<UIMessage>; data?: Record<string, any> }) => data,
)
.handler(({ data }) =>
toServerSentEventsResponse(
chat({
adapter: openaiText('gpt-5.2'),
messages: data.messages as any,
systemPrompts: [
'You are a helpful assistant. Keep replies short and friendly.',
],
}),
),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore runtime validation for chatFn.

This handler currently accepts unchecked input: the identity inputValidator lets malformed payloads through, and data.messages as any hides that at the call site. Please switch this to a real schema so bad requests fail before reaching chat().

Suggested fix
 export const chatFn = createServerFn({ method: 'POST' })
   .inputValidator(
-    (data: { messages: Array<UIMessage>; data?: Record<string, any> }) => data,
+    z.object({
+      messages: z.array(z.any()),
+      data: z.record(z.unknown()).optional(),
+    }),
   )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/lib/server-fns.ts` around lines 380 - 394, Replace
the no-op inputValidator on chatFn with a real runtime schema that validates the
overall payload shape (e.g., an object with messages: Array<UIMessage> and
optional data), validate each message fields used by chat (role, content, id,
etc.), and remove the unsafe cast "data.messages as any" so the handler passes
the typed/validated messages into chat; update the inputValidator call to use
that schema (or a Zod schema) and ensure chatFn.handler expects the validated
type before calling chat/openaiText/toServerSentEventsResponse.

})
const [input, setInput] = useState('')

const handleSubmit = (e: React.FormEvent) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking TS config for UMD global access + JSX mode..."
fd -i 'tsconfig*.json' | while read -r f; do
  echo "### $f"
  cat "$f" | jq '.compilerOptions | {jsx, allowUmdGlobalAccess, types}'
done

echo
echo "Searching for React namespace type usage in TSX files..."
rg -nP --type=tsx '\bReact\.[A-Za-z_]+' -C1

Repository: TanStack/ai

Length of output: 1771


🏁 Script executed:

# Attempt to examine the file and TypeScript config
# First, let's check if the repository is available and explore the file structure

# Check for the file in question
git ls-files | grep -E "(server-fn-chat|tsconfig)" | head -20

Repository: TanStack/ai

Length of output: 931


🏁 Script executed:

# If the file exists, read its contents
if git ls-files | grep -q "examples/ts-react-chat/src/routes/server-fn-chat.tsx"; then
  echo "=== File: server-fn-chat.tsx (lines 1-50) ==="
  head -50 "examples/ts-react-chat/src/routes/server-fn-chat.tsx"
  echo ""
  echo "=== Checking imports and line 18 context ==="
  sed -n '1,30p' "examples/ts-react-chat/src/routes/server-fn-chat.tsx" | cat -n
fi

Repository: TanStack/ai

Length of output: 3156


🏁 Script executed:

# Check tsconfig files
echo "=== Looking for tsconfig files ==="
find . -name "tsconfig*.json" -type f | head -10 | while read f; do
  echo "### $f"
  head -30 "$f"
  echo ""
done

Repository: TanStack/ai

Length of output: 5905


React.FormEvent type reference will fail in strict TypeScript mode without a React import.

Line 18 uses React.FormEvent but only imports useState from React. With moduleResolution: "bundler" and allowUmdGlobalAccess: false (default), the React namespace is unavailable. Fix by importing the type directly: import type { FormEvent } from 'react' and use FormEvent instead of React.FormEvent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx` at line 18, The handler
signature uses React.FormEvent but the React namespace isn't available under
strict TS settings; update imports to include the type and switch the parameter
type: import type { FormEvent } from 'react' and change the handleSubmit
signature from (e: React.FormEvent) to (e: FormEvent) in the handleSubmit
function so the type resolves under moduleResolution: "bundler" and
allowUmdGlobalAccess: false.

Comment on lines +31 to +33
useChat(&#123; fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) =&gt;
chatFn({'{'} data, signal {'}'}) &#125;)
</code>{' '}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Displayed fetcher snippet uses the wrong chatFn call shape.

The rendered example shows chatFn({ data, signal }), but the real call is chatFn({ data: { messages }, signal }). This can cause copy/paste confusion.

✏️ Suggested text fix
-            chatFn({'{'} data, signal {'}'}) &#125;)
+            chatFn({'{'} data: {'{'} messages {'}'}, signal {'}'}) &#125;)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useChat(&#123; fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) =&gt;
chatFn({'{'} data, signal {'}'}) &#125;)
</code>{' '}
useChat(&#123; fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) =&gt;
chatFn({'{'} data: {'{'} messages {'}'}, signal {'}'}) &#125;)
</code>{' '}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx` around lines 31 - 33,
The fetcher example passed to useChat uses the wrong call shape for chatFn;
update the fetcher so it forwards the messages correctly by calling chatFn with
data containing messages and the signal (i.e., call chatFn with an object whose
data property wraps messages and includes signal) — locate the useChat fetcher
and replace the current chatFn({ data, signal }) invocation with the correct
shape chatFn({ data: { messages }, signal }) so copy/pasted examples work as
intended.

Comment on lines +78 to 83
// A non-empty trailing buffer means the connection was cut mid-line.
// Surface this as an error so the chat client transitions to 'error'
// state instead of silently presenting a partial stream as success.
if (buffer.trim()) {
yield buffer
throw new StreamTruncatedError()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Abort can be misclassified as StreamTruncatedError when a partial line is buffered.

After stop(), breaking the loop still runs trailing-buffer validation; if abort happens mid-line, this can wrongly surface an error instead of a clean abort.

✅ Minimal fix
-    if (buffer.trim()) {
+    if (!abortSignal?.aborted && buffer.trim()) {
       throw new StreamTruncatedError()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-client/src/connection-adapters.ts` around lines 78 -
83, The trailing-buffer check is misclassifying an intentional abort as a
StreamTruncatedError; update the validation so it only throws
StreamTruncatedError when the stream ended unexpectedly, not when the operation
was aborted. Specifically, in the block that inspects buffer (the code that
currently does if (buffer.trim()) { throw new StreamTruncatedError() }), add a
guard that detects an abort (e.g., check controller.signal?.aborted or a
local/instance flag set by stop()) and skip throwing if aborted—only throw
StreamTruncatedError when buffer.trim() is true AND the abort signal/stop flag
is false. Ensure you reference and use the existing stop() abort mechanism and
the StreamTruncatedError symbol so behavior remains unchanged for genuine
truncation.

Comment on lines +139 to +150
const chunk = JSON.parse(data) as StreamChunk
if ('threadId' in chunk && typeof chunk.threadId === 'string') {
lastThreadId = chunk.threadId
}
if ('runId' in chunk && typeof chunk.runId === 'string') {
lastRunId = chunk.runId
}
if ('model' in chunk && typeof chunk.model === 'string') {
lastModel = chunk.model
}
yield chunk
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate parsed stream payloads before casting to StreamChunk.

Both SSE and HTTP-stream paths currently do JSON.parse(...) as StreamChunk on untrusted network input. This should be schema-validated before use.

As per coding guidelines: packages/typescript/**/src/**/*.ts: "Use Zod for schema validation and tool definition across the library".

Also applies to: 479-481

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-client/src/connection-adapters.ts` around lines 139 -
150, The code is JSON.parse'ing untrusted stream data and casting to StreamChunk
without validation; update the parsing to validate against a Zod schema (define
a StreamChunkSchema using zod matching StreamChunk fields) and use
StreamChunkSchema.parse or safeParse on the parsed object before assigning
values or yielding; replace direct casts in the block that sets lastThreadId,
lastRunId, lastModel and yields chunk (and the similar occurrences around the
other mentioned locations) with the validated result, handling validation
failures (log/skip/break as appropriate) instead of trusting the cast.

Comment on lines +91 to 95
it('should throw if neither connection nor fetcher is provided', () => {
expect(() => new ChatClient({} as any)).toThrow(
'Connection adapter is required',
'either `connection` or `fetcher` is required',
)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cover the both provided branch as well.

This assertion only exercises the empty-transport path. The new constructor contract also rejects configurations that include both connection and fetcher, so add a regression check for that branch too.

Suggested test addition
     it('should throw if neither connection nor fetcher is provided', () => {
       expect(() => new ChatClient({} as any)).toThrow(
         'either `connection` or `fetcher` is required',
       )
     })
+
+    it('should throw if both connection and fetcher are provided', () => {
+      expect(() =>
+        new ChatClient({
+          connection: createMockConnectionAdapter(),
+          fetcher: vi.fn(),
+        } as any),
+      ).toThrow('pass either `connection` or `fetcher`, not both.')
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should throw if neither connection nor fetcher is provided', () => {
expect(() => new ChatClient({} as any)).toThrow(
'Connection adapter is required',
'either `connection` or `fetcher` is required',
)
})
it('should throw if neither connection nor fetcher is provided', () => {
expect(() => new ChatClient({} as any)).toThrow(
'either `connection` or `fetcher` is required',
)
})
it('should throw if both connection and fetcher are provided', () => {
expect(() =>
new ChatClient({
connection: createMockConnectionAdapter(),
fetcher: vi.fn(),
} as any),
).toThrow('pass either `connection` or `fetcher`, not both.')
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-client/tests/chat-client.test.ts` around lines 91 -
95, Add a regression test that exercises the "both provided" branch of the
ChatClient constructor: construct a new ChatClient passing both a `connection`
and a `fetcher` (e.g., stub/mocked objects) and assert it throws the same
constructor error (the message about requiring either `connection` or
`fetcher`). Target the ChatClient constructor in the existing test file and
mirror the existing pattern used in the empty-transport test so both branches
(none provided and both provided) are covered.

Comment on lines +26 to +40
test('fetcher mode — streams an SSE Response through useChat({ fetcher })', async ({
page,
testId,
aimockPort,
}) => {
await page.goto(
featureUrl(provider, 'chat', testId, aimockPort, 'fetcher'),
)

await sendMessage(page, '[chat] recommend a guitar')
await waitForResponse(page)

const response = await getLastAssistantMessage(page)
expect(response).toContain('Fender Stratocaster')
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a fetcher-specific assertion.

This case still mirrors the SSE flow closely enough that it would pass even if the route silently fell back to the old transport. Please assert something that only happens in fetcher mode, such as the selected mode in the UI or the request payload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testing/e2e/tests/chat.spec.ts` around lines 26 - 40, The test 'fetcher mode
— streams an SSE Response through useChat({ fetcher })' currently mirrors the
SSE flow and needs a fetcher-specific assertion; update the test (the async test
block using featureUrl, sendMessage, waitForResponse, getLastAssistantMessage)
to additionally assert a fetcher-only signal—for example, check the UI mode
indicator element text/value that shows "fetcher" is selected after page.goto,
or intercept and inspect the outgoing request body to confirm it used the
fetcher payload shape (e.g., contains a fetcher flag or specific header) before
sending the message; use existing helpers on the page to query the mode element
or route/intercept the request and add an expect that fails if the fetcher
marker is absent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ai-client): useChat server functions

1 participant